Skip to content
Draft
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
56 changes: 42 additions & 14 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ function App() {
const [browserPath, setBrowserPath] = useState(() => localStorage.getItem(STORAGE_KEY_BROWSER_PATH) || '')
const [toast, setToast] = useState<{message: string, type: 'success' | 'error'} | null>(null)
const [loading, setLoading] = useState(false)
const [isSelectingAll, setIsSelectingAll] = useState(false)
const [updateInfo, setUpdateInfo] = useState<main.UpdateInfo | null>(null)
const [showHistory, setShowHistory] = useState(false)
const [showAnalysis, setShowAnalysis] = useState(false)
Expand Down Expand Up @@ -448,20 +449,39 @@ function App() {

const handleDeselectAll = () => setSelectedPaths(new Set())

const handleSelectAll = useCallback(() => {
if (groups.length === 0) return
setSelectedPaths(prev => {
const next = new Set(prev)
groups.forEach(group => {
const handleSelectAll = useCallback(async () => {
if (groups.length === 0 || isSelectingAll) return
setIsSelectingAll(true)
try {
const next = new Set<string>()
const currentGroups = groups
const allGroups = [...currentGroups]
for (let page = 1; page <= pageInfo.totalPages; page++) {
if (page === pageInfo.page) continue
try {
const pageData = await GetGroupsPage(page, pageInfo.pageSize, sortBy, searchQuery) as main.GroupPage
allGroups.push(...(pageData.groups || []))
} catch (err) {
console.error(`SelectAll page ${page} error:`, err)
}
}

allGroups.forEach(group => {
group.files.forEach(file => {
if (!next.has(file.path)) {
next.add(file.path)
}
next.add(file.path)
})
})
return next
})
}, [groups])
setSelectedPaths(prev => {
const merged = new Set(prev)
next.forEach(path => merged.add(path))
return merged
})
} catch (err) {
console.error('SelectAll error:', err)
} finally {
setIsSelectingAll(false)
}
}, [groups, isSelectingAll, pageInfo.page, pageInfo.pageSize, pageInfo.totalPages, searchQuery, sortBy])

const handleDismissUpdate = () => {
if (updateInfo) {
Expand Down Expand Up @@ -521,7 +541,7 @@ function App() {
// Ctrl+A - select all files
if (e.ctrlKey && e.key === 'a') {
e.preventDefault()
handleSelectAll()
void handleSelectAll()
}

// Escape - deselect all
Expand Down Expand Up @@ -665,15 +685,23 @@ function App() {
onToggleGroup={handleToggleGroup}
onDeleteFile={handleDeleteFile}
onPageChange={(page) => setPageInfo(prev => ({...prev, page}))}
onSortChange={setSortBy}
onSearchChange={setSearchQuery}
onSortChange={(value) => {
setSortBy(value)
setPageInfo(prev => ({...prev, page: 1}))
}}
onSearchChange={(value) => {
setSearchQuery(value)
setPageInfo(prev => ({...prev, page: 1}))
}}
/>
<ActionBar
selectedCount={selectedPaths.size}
totalWasted={totalWasted}
folders={folders}
groups={groups}
onSelectFolderDuplicates={handleSelectFolderDuplicates}
onSelectAll={() => void handleSelectAll()}
selectingAll={isSelectingAll}
onSmartSelect={handleSmartSelect}
onDelete={handleDelete}
onDeselectAll={handleDeselectAll}
Expand Down
12 changes: 11 additions & 1 deletion frontend/src/components/ActionBar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {DuplicateGroup} from '../types'
import {Button} from '@/components/ui/button'
import {Trash2, X, FolderOpen, Sparkles} from 'lucide-react'
import {Trash2, X, FolderOpen, Sparkles, CheckCheck} from 'lucide-react'
import {useI18n} from '../i18n/context'

interface ActionBarProps {
Expand All @@ -9,6 +9,8 @@ interface ActionBarProps {
folders: string[]
groups: DuplicateGroup[]
onSelectFolderDuplicates: (folderPrefix: string) => void
onSelectAll: () => void
selectingAll: boolean
onSmartSelect: () => void
onDelete: () => void
onDeselectAll: () => void
Expand All @@ -32,6 +34,8 @@ function ActionBar({
folders,
groups,
onSelectFolderDuplicates,
onSelectAll,
selectingAll,
onSmartSelect,
onDelete,
onDeselectAll
Expand All @@ -45,6 +49,12 @@ function ActionBar({
{t('actionBar.groupSummary', {count: groups.length, size: formatSize(totalWasted)})}
</div>
<div className="flex items-center gap-2 flex-wrap">
{groups.length > 0 && (
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={onSelectAll} disabled={selectingAll}>
<CheckCheck className="h-3 w-3 mr-1"/>
{selectingAll ? t('results.loading') : t('actionBar.selectAllResults')}
</Button>
)}
{groups.length > 0 && (
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={onSmartSelect}>
<Sparkles className="h-3 w-3 mr-1"/>
Expand Down
61 changes: 60 additions & 1 deletion frontend/src/components/Results.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {Badge} from '@/components/ui/badge'
import {Checkbox} from '@/components/ui/checkbox'
import {Dialog, DialogContent, DialogHeader, DialogTitle} from '@/components/ui/dialog'
import {cn} from '@/lib/utils'
import {FileVideo, Image, Music, FileText, Archive, File, Trash2, FolderOpen, ExternalLink, Eye} from 'lucide-react'
import {FileVideo, Image, Music, FileText, Archive, File, Trash2, FolderOpen, ExternalLink, Eye, ChevronLeft, ChevronRight} from 'lucide-react'

interface ResultsProps {
groups: DuplicateGroup[]
Expand Down Expand Up @@ -125,6 +125,7 @@ function Results({
const {t} = useI18n()
const [activeFilter, setActiveFilter] = useState<FileType>('all')
const [searchInput, setSearchInput] = useState(searchQuery)
const [pageInput, setPageInput] = useState(String(pageInfo.page))
const [previewFile, setPreviewFile] = useState<main.FileInfo | null>(null)
const [imagePreviewUrls, setImagePreviewUrls] = useState<Record<string, string>>({})
const [brokenPreviewPaths, setBrokenPreviewPaths] = useState<Record<string, true>>({})
Expand All @@ -135,6 +136,23 @@ function Results({
return () => clearTimeout(timer)
}, [searchInput, onSearchChange])

useEffect(() => {
setSearchInput(searchQuery)
}, [searchQuery])

useEffect(() => {
setPageInput(String(pageInfo.page))
}, [pageInfo.page])

const applyPageInput = useCallback(() => {
const page = Number(pageInput)
if (Number.isFinite(page) && page >= 1 && page <= pageInfo.totalPages) {
onPageChange(page)
return
}
setPageInput(String(pageInfo.page))
}, [onPageChange, pageInfo.page, pageInfo.totalPages, pageInput])

const ensureImagePreview = useCallback(async (filePath: string) => {
if (imagePreviewUrls[filePath] || brokenPreviewPaths[filePath] || loadingImagePaths[filePath]) {
return
Expand Down Expand Up @@ -478,6 +496,47 @@ function Results({
{previewFile && renderPreviewContent(previewFile)}
</DialogContent>
</Dialog>

{pageInfo.totalPages > 1 && (
<div className="mt-4 flex items-center justify-end gap-2 text-xs text-muted-foreground">
<Button
variant="outline"
size="sm"
className="h-7 px-2"
disabled={pageInfo.page <= 1}
onClick={() => onPageChange(pageInfo.page - 1)}
>
<ChevronLeft className="h-3.5 w-3.5"/>
</Button>
<span>
{t('results.pageStatus', {page: pageInfo.page, totalPages: pageInfo.totalPages, total: pageInfo.total})}
</span>
<input
type="number"
min={1}
max={pageInfo.totalPages}
aria-label={t('results.jumpToPage')}
className="h-7 w-16 rounded-md border bg-background px-2 text-xs"
value={pageInput}
onChange={(e) => setPageInput(e.target.value)}
onBlur={applyPageInput}
onKeyDown={(e) => {
if (e.key === 'Enter') {
applyPageInput()
}
}}
/>
<Button
variant="outline"
size="sm"
className="h-7 px-2"
disabled={pageInfo.page >= pageInfo.totalPages}
onClick={() => onPageChange(pageInfo.page + 1)}
>
<ChevronRight className="h-3.5 w-3.5"/>
</Button>
</div>
)}
</div>
)
}
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/i18n/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ const enUS: typeof import('./zh-CN').default = {

'actionBar.groupSummary': '{count} duplicate groups, reclaimable {size}',
'actionBar.selectAll': 'Select All:',
'actionBar.selectAllResults': 'Select All Results',
'actionBar.selected': '{count} files selected',
'actionBar.deselectAll': 'Deselect All',
'actionBar.deleteSelected': 'Delete Selected ({count})',
Expand Down Expand Up @@ -151,6 +152,8 @@ const enUS: typeof import('./zh-CN').default = {
'results.openLocation': 'Open Location',
'results.openFile': 'Open File',
'results.deleteFile': 'Delete This File',
'results.jumpToPage': 'Jump to page',
'results.pageStatus': 'Page {page}/{totalPages} · {total} groups',
'actionBar.smartSelect': 'Smart Select',

'scan.groupingByFilename': 'Grouping results by filename...',
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ const zhCN = {

'actionBar.groupSummary': '共 {count} 组重复,可释放 {size}',
'actionBar.selectAll': '全选:',
'actionBar.selectAllResults': '全选查询结果',
'actionBar.selected': '已选中 {count} 个文件',
'actionBar.deselectAll': '取消全选',
'actionBar.deleteSelected': '删除选中 ({count})',
Expand Down Expand Up @@ -151,6 +152,8 @@ const zhCN = {
'results.openLocation': '打开所在目录',
'results.openFile': '打开文件',
'results.deleteFile': '删除此文件',
'results.jumpToPage': '跳转到页码',
'results.pageStatus': '第 {page}/{totalPages} 页 · 共 {total} 组',
'actionBar.smartSelect': '智能选择',

'scan.groupingByFilename': '正在按文件名整理结果...',
Expand Down
27 changes: 24 additions & 3 deletions store.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,16 @@ func (s *Store) GetGroupsByScanID(scanID int64, page, pageSize int, sortBy, sear
}

var total int
s.db.QueryRow("SELECT COUNT(*) FROM groups WHERE scan_id = ?", scanID).Scan(&total)
if searchQuery != "" {
q := "%" + escapeLikePattern(searchQuery) + "%"
s.db.QueryRow(
`SELECT COUNT(*) FROM groups g
WHERE g.scan_id = ? AND EXISTS (SELECT 1 FROM files f WHERE f.group_id = g.id AND f.name LIKE ? ESCAPE '\')`,
scanID, q,
).Scan(&total)
} else {
s.db.QueryRow("SELECT COUNT(*) FROM groups WHERE scan_id = ?", scanID).Scan(&total)
}
totalPages := (total + pageSize - 1) / pageSize

orderBy := "g.file_count * g.size DESC"
Expand All @@ -286,10 +295,10 @@ func (s *Store) GetGroupsByScanID(scanID int64, page, pageSize int, sortBy, sear
var rows *sql.Rows
var err error
if searchQuery != "" {
q := "%" + searchQuery + "%"
q := "%" + escapeLikePattern(searchQuery) + "%"
rows, err = s.db.Query(
fmt.Sprintf(`SELECT g.id, g.hash, g.size, g.file_count FROM groups g
WHERE g.scan_id = ? AND EXISTS (SELECT 1 FROM files f WHERE f.group_id = g.id AND f.name LIKE ?)
WHERE g.scan_id = ? AND EXISTS (SELECT 1 FROM files f WHERE f.group_id = g.id AND f.name LIKE ? ESCAPE '\')
ORDER BY %s LIMIT ? OFFSET ?`, orderBy),
scanID, q, pageSize, offset,
)
Expand Down Expand Up @@ -335,6 +344,18 @@ func (s *Store) GetGroupsByScanID(scanID int64, page, pageSize int, sortBy, sear
}, nil
}

func escapeLikePattern(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if r == '\\' || r == '%' || r == '_' {
b.WriteByte('\\')
}
b.WriteRune(r)
}
return b.String()
}

// GetStatsByScanID returns stats for a specific scan
func (s *Store) GetStatsByScanID(scanID int64) (ScanStats, error) {
var stats ScanStats
Expand Down