diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dbcd382..f5e1757 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(null) const [showHistory, setShowHistory] = useState(false) const [showAnalysis, setShowAnalysis] = useState(false) @@ -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() + 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) { @@ -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 @@ -665,8 +685,14 @@ 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})) + }} /> void handleSelectAll()} + selectingAll={isSelectingAll} onSmartSelect={handleSmartSelect} onDelete={handleDelete} onDeselectAll={handleDeselectAll} diff --git a/frontend/src/components/ActionBar.tsx b/frontend/src/components/ActionBar.tsx index db63f7e..ac640c2 100644 --- a/frontend/src/components/ActionBar.tsx +++ b/frontend/src/components/ActionBar.tsx @@ -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 { @@ -9,6 +9,8 @@ interface ActionBarProps { folders: string[] groups: DuplicateGroup[] onSelectFolderDuplicates: (folderPrefix: string) => void + onSelectAll: () => void + selectingAll: boolean onSmartSelect: () => void onDelete: () => void onDeselectAll: () => void @@ -32,6 +34,8 @@ function ActionBar({ folders, groups, onSelectFolderDuplicates, + onSelectAll, + selectingAll, onSmartSelect, onDelete, onDeselectAll @@ -45,6 +49,12 @@ function ActionBar({ {t('actionBar.groupSummary', {count: groups.length, size: formatSize(totalWasted)})}
+ {groups.length > 0 && ( + + )} {groups.length > 0 && ( + + {t('results.pageStatus', {page: pageInfo.page, totalPages: pageInfo.totalPages, total: pageInfo.total})} + + setPageInput(e.target.value)} + onBlur={applyPageInput} + onKeyDown={(e) => { + if (e.key === 'Enter') { + applyPageInput() + } + }} + /> + +
+ )} ) } diff --git a/frontend/src/i18n/en-US.ts b/frontend/src/i18n/en-US.ts index 4bf85e0..7c711f9 100644 --- a/frontend/src/i18n/en-US.ts +++ b/frontend/src/i18n/en-US.ts @@ -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})', @@ -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...', diff --git a/frontend/src/i18n/zh-CN.ts b/frontend/src/i18n/zh-CN.ts index 28a6310..7946b3e 100644 --- a/frontend/src/i18n/zh-CN.ts +++ b/frontend/src/i18n/zh-CN.ts @@ -112,6 +112,7 @@ const zhCN = { 'actionBar.groupSummary': '共 {count} 组重复,可释放 {size}', 'actionBar.selectAll': '全选:', + 'actionBar.selectAllResults': '全选查询结果', 'actionBar.selected': '已选中 {count} 个文件', 'actionBar.deselectAll': '取消全选', 'actionBar.deleteSelected': '删除选中 ({count})', @@ -151,6 +152,8 @@ const zhCN = { 'results.openLocation': '打开所在目录', 'results.openFile': '打开文件', 'results.deleteFile': '删除此文件', + 'results.jumpToPage': '跳转到页码', + 'results.pageStatus': '第 {page}/{totalPages} 页 · 共 {total} 组', 'actionBar.smartSelect': '智能选择', 'scan.groupingByFilename': '正在按文件名整理结果...', diff --git a/store.go b/store.go index 1fb9b9c..5e134ee 100644 --- a/store.go +++ b/store.go @@ -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" @@ -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, ) @@ -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