Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ dist-ssr
dist-electron
release
*.local
AGENTS.md

# Editor directories and files
.vscode/.debug.env
Expand Down
117 changes: 111 additions & 6 deletions src/components/Sidebars/FileSideBar/FileItemRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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)
}
Comment on lines +157 to +162
Copy link

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.


// 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
Copy link

Choose a reason for hiding this comment

The 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>
</>
Expand Down
38 changes: 38 additions & 0 deletions src/contexts/FileContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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>({
Expand Down Expand Up @@ -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
Copy link

Choose a reason for hiding this comment

The 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 onlyFiles = vaultFilesFlattened
const flattenedItems = 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(
Expand Down