Skip to content

Commit bbbe940

Browse files
committed
feat(v1.0.2): scan consistency, UI fixes, auto-update & test coverage
## Critical Bug Fixes - **Scan Consistency**: Fixed 60→26 item inconsistency bug - Added backend deduplication in scan_service.go - Created shared createDefaultScanOptions() utility - Auto-scan and manual scan now use identical options - **Table Sort Duplication**: Fixed duplicate items when sorting - Added frontend deduplication using Map - Sort now cycles cleanly: Original → Desc → Asc ## New Features - **Auto-Update Check**: GitHub Releases API integration - Check for updates on startup (configurable) - 5-minute cache to prevent rate limiting - Manual check button in Settings - **Auto-Rescan After Clean**: Automatically refresh after deletion - Triggers full scan 500ms after clean completes - Shows toast notification during rescan ## UI/UX Improvements - **Table Format**: Converted to proper HTML table with sticky header - **Sortable Columns**: Size column with visual sort indicators - **Treemap**: Removed 50-item limit, now shows all items - **Type Column**: Auto-width to prevent text wrapping - **Horizontal Scroll**: Added for narrow windows ## Testing & Quality - Added 22 unit tests (34.2% coverage) - scan_service_test.go (8 tests) - clean_service_test.go (7 tests) - settings_service_test.go (7 tests) - All tests passing, no race conditions ## Technical Improvements - DRY: Extracted shared scan options - Type safety: Full TypeScript coverage - Performance: Memoized calculations - Maintainability: Clear separation of concerns ## Files Changed New (6): - internal/services/update_service.go - internal/services/*_test.go (3 files) - frontend/src/components/update-notification.tsx - frontend/src/lib/scan-utils.ts Modified (14): - internal/services/scan_service.go - internal/services/settings_service.go - app.go - frontend/src/**/*.tsx (6 files) - go.mod, go.sum See plans/reports/20251217-v1.0.2-release-notes.md for details
1 parent 25ef7cd commit bbbe940

21 files changed

+1250
-107
lines changed

app.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type App struct {
1515
treeService *services.TreeService
1616
cleanService *services.CleanService
1717
settingsService *services.SettingsService
18+
updateService *services.UpdateService
1819
}
1920

2021
func NewApp() *App {
@@ -49,6 +50,10 @@ func NewApp() *App {
4950
a.settingsService = services.NewSettingsService()
5051
log.Println("✅ SettingsService initialized")
5152

53+
// Initialize update service
54+
a.updateService = services.NewUpdateService("1.0.2", "thanhdevapp", "mac-dev-cleaner-cli")
55+
log.Println("✅ UpdateService initialized")
56+
5257
log.Println("🎉 All services initialized successfully!")
5358
return a
5459
}
@@ -66,6 +71,9 @@ func (a *App) startup(ctx context.Context) {
6671
if a.cleanService != nil {
6772
a.cleanService.SetContext(ctx)
6873
}
74+
if a.updateService != nil {
75+
a.updateService.SetContext(ctx)
76+
}
6977
}
7078

7179
func (a *App) shutdown(ctx context.Context) {
@@ -137,3 +145,17 @@ func (a *App) UpdateSettings(settings services.Settings) error {
137145
}
138146
return a.settingsService.Update(settings)
139147
}
148+
149+
// UpdateService methods exposed to frontend
150+
func (a *App) CheckForUpdates() (*services.UpdateInfo, error) {
151+
if a.updateService == nil {
152+
return nil, nil
153+
}
154+
return a.updateService.CheckForUpdates()
155+
}
156+
157+
func (a *App) ClearUpdateCache() {
158+
if a.updateService != nil {
159+
a.updateService.ClearCache()
160+
}
161+
}

dev-cleaner

0 Bytes
Binary file not shown.

frontend/src/App.tsx

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
1-
import { useEffect } from 'react'
1+
import { useEffect, useState } from 'react'
22
import { ThemeProvider } from '@/components/theme-provider'
33
import { Toolbar } from '@/components/toolbar'
44
import { Sidebar } from '@/components/sidebar'
55
import { ScanResults } from '@/components/scan-results'
66
import { SettingsDialog } from '@/components/settings-dialog'
7+
import { UpdateNotification } from '@/components/update-notification'
78
import { Toaster } from '@/components/ui/toaster'
89
import { useUIStore } from '@/store/ui-store'
910
import { Scan, GetSettings } from '../wailsjs/go/main/App'
10-
import { types, services } from '../wailsjs/go/models'
11+
import { services } from '../wailsjs/go/models'
12+
import { createDefaultScanOptions } from '@/lib/scan-utils'
1113

1214
function App() {
1315
const { isSettingsOpen, toggleSettings, setScanning, setViewMode } = useUIStore()
16+
const [checkForUpdates, setCheckForUpdates] = useState(false)
1417

1518

1619
// Load settings and apply them on app mount
@@ -27,20 +30,18 @@ function App() {
2730
console.log('Applied default view:', settings.defaultView)
2831
}
2932

33+
// Check for updates if enabled
34+
if (settings.checkAutoUpdate) {
35+
console.log('Auto-update check enabled, will check for updates...')
36+
setCheckForUpdates(true)
37+
}
38+
3039
// Auto-scan if setting is enabled
3140
if (settings.autoScan) {
3241
console.log('Auto-scan enabled, starting scan...')
3342
setScanning(true)
3443
try {
35-
const opts = new types.ScanOptions({
36-
IncludeXcode: true,
37-
IncludeAndroid: true,
38-
IncludeNode: true,
39-
IncludeReactNative: true,
40-
IncludeCache: true,
41-
ProjectRoot: '/Users',
42-
MaxDepth: settings.maxDepth || 5
43-
})
44+
const opts = createDefaultScanOptions(settings)
4445
await Scan(opts)
4546
console.log('Auto-scan complete')
4647
} catch (error) {
@@ -56,14 +57,7 @@ function App() {
5657
// If settings fail, scan anyway with defaults
5758
setScanning(true)
5859
try {
59-
const opts = new types.ScanOptions({
60-
IncludeXcode: true,
61-
IncludeAndroid: true,
62-
IncludeNode: true,
63-
IncludeReactNative: true,
64-
IncludeCache: true,
65-
ProjectRoot: '/Users'
66-
})
60+
const opts = createDefaultScanOptions()
6761
await Scan(opts)
6862
} catch (scanError) {
6963
console.error('Fallback scan failed:', scanError)
@@ -96,6 +90,9 @@ function App() {
9690
open={isSettingsOpen}
9791
onOpenChange={toggleSettings}
9892
/>
93+
94+
{/* Update Notification */}
95+
<UpdateNotification checkOnMount={checkForUpdates} />
9996
</div>
10097
</ThemeProvider>
10198
)

frontend/src/components/file-tree-list.tsx

Lines changed: 121 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { memo } from 'react';
1+
import { memo, useState, useMemo } from 'react';
22
import { Checkbox } from "@/components/ui/checkbox";
33
import { Badge } from "@/components/ui/badge";
44
import { cn, formatBytes } from "@/lib/utils";
5-
import { Folder, Box, Smartphone, AppWindow, Database, Atom } from 'lucide-react';
5+
import { Folder, Box, Smartphone, AppWindow, Database, Atom, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
66
import { types } from '../../wailsjs/go/models';
77

88
interface FileTreeListProps {
@@ -13,6 +13,8 @@ interface FileTreeListProps {
1313
className?: string;
1414
}
1515

16+
type SortDirection = 'asc' | 'desc' | null;
17+
1618
const Row = memo(({ item, isSelected, onToggleSelection }: {
1719
item: types.ScanResult;
1820
isSelected: boolean;
@@ -49,42 +51,47 @@ const Row = memo(({ item, isSelected, onToggleSelection }: {
4951
const displayPath = item.path;
5052

5153
return (
52-
<div
54+
<tr
5355
className={cn(
54-
"flex items-center px-4 py-2 hover:bg-muted/50 transition-colors border-b border-border/40 min-h-[56px] cursor-pointer",
56+
"hover:bg-muted/50 transition-colors cursor-pointer",
5557
isSelected && "bg-accent/50"
5658
)}
5759
onClick={() => onToggleSelection(item.path)}
5860
>
59-
<div className="flex items-center gap-3 flex-1 min-w-0">
61+
<td className="px-4 py-2 w-10">
6062
<Checkbox
6163
checked={isSelected}
6264
onCheckedChange={() => onToggleSelection(item.path)}
63-
className="mr-1"
64-
onClick={(e) => e.stopPropagation()} // Prevent double toggle
65+
className="shrink-0"
66+
onClick={(e) => e.stopPropagation()}
6567
/>
66-
67-
{getIcon()}
68-
69-
<div className="flex flex-col min-w-0 flex-1">
70-
<div className="flex items-center gap-2">
71-
<span className="font-medium truncate text-sm" title={item.path}>
72-
{displayName}
73-
</span>
74-
<Badge variant={getBadgeVariant(item.type)} className="text-[10px] h-4 px-1 uppercase">
75-
{item.type}
76-
</Badge>
77-
</div>
78-
<span className="text-xs text-muted-foreground truncate" title={item.path}>
79-
{displayPath}
80-
</span>
68+
</td>
69+
<td className="px-4 py-2 w-10">
70+
<div className="shrink-0">
71+
{getIcon()}
8172
</div>
82-
</div>
83-
84-
<div className="text-sm font-mono text-muted-foreground whitespace-nowrap pl-4">
85-
{formatBytes(item.size)}
86-
</div>
87-
</div>
73+
</td>
74+
<td className="px-4 py-2 min-w-[200px]">
75+
<span className="font-medium text-sm truncate block" title={displayName}>
76+
{displayName}
77+
</span>
78+
</td>
79+
<td className="px-4 py-2 whitespace-nowrap">
80+
<Badge variant={getBadgeVariant(item.type)} className="text-[10px] h-4 px-1.5 uppercase shrink-0 whitespace-nowrap">
81+
{item.type}
82+
</Badge>
83+
</td>
84+
<td className="px-4 py-2 min-w-[300px]">
85+
<span className="text-xs text-muted-foreground truncate block" title={displayPath}>
86+
{displayPath}
87+
</span>
88+
</td>
89+
<td className="px-4 py-2 text-right w-32">
90+
<span className="text-sm font-mono text-muted-foreground whitespace-nowrap">
91+
{formatBytes(item.size)}
92+
</span>
93+
</td>
94+
</tr>
8895
);
8996
});
9097

@@ -97,6 +104,51 @@ export function FileTreeList({
97104
height = "100%",
98105
className
99106
}: FileTreeListProps) {
107+
const [sortDirection, setSortDirection] = useState<SortDirection>(null);
108+
109+
// Deduplicate items by path to prevent rendering duplicates
110+
const uniqueItems = useMemo(() => {
111+
const seen = new Map<string, types.ScanResult>();
112+
items.forEach(item => {
113+
if (!seen.has(item.path)) {
114+
seen.set(item.path, item);
115+
}
116+
});
117+
return Array.from(seen.values());
118+
}, [items]);
119+
120+
// Sort items based on size
121+
const sortedItems = useMemo(() => {
122+
if (!sortDirection) return uniqueItems;
123+
124+
// Create a shallow copy and sort
125+
const itemsCopy = uniqueItems.slice();
126+
itemsCopy.sort((a, b) => {
127+
if (sortDirection === 'asc') {
128+
return a.size - b.size;
129+
} else {
130+
return b.size - a.size;
131+
}
132+
});
133+
134+
return itemsCopy;
135+
}, [uniqueItems, sortDirection]);
136+
137+
const toggleSort = () => {
138+
if (sortDirection === null) {
139+
setSortDirection('desc'); // First click: largest first
140+
} else if (sortDirection === 'desc') {
141+
setSortDirection('asc'); // Second click: smallest first
142+
} else {
143+
setSortDirection(null); // Third click: back to original
144+
}
145+
};
146+
147+
const getSortIcon = () => {
148+
if (sortDirection === 'desc') return <ArrowDown className="h-3 w-3" />;
149+
if (sortDirection === 'asc') return <ArrowUp className="h-3 w-3" />;
150+
return <ArrowUpDown className="h-3 w-3" />;
151+
};
100152

101153
if (items.length === 0) {
102154
return (
@@ -107,15 +159,47 @@ export function FileTreeList({
107159
}
108160

109161
return (
110-
<div className={cn("h-full w-full overflow-auto", className)}>
111-
{items.map((item) => (
112-
<Row
113-
key={item.path}
114-
item={item}
115-
isSelected={selectedPaths.includes(item.path)}
116-
onToggleSelection={onToggleSelection}
117-
/>
118-
))}
162+
<div className={cn("h-full w-full overflow-x-auto overflow-y-auto", className)}>
163+
<table className="w-full min-w-[800px]">
164+
<thead className="sticky top-0 bg-muted/80 backdrop-blur-sm border-b border-border z-10">
165+
<tr>
166+
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider w-10">
167+
<Checkbox className="opacity-50 cursor-not-allowed" disabled />
168+
</th>
169+
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider w-10">
170+
{/* Icon column */}
171+
</th>
172+
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider min-w-[200px]">
173+
Name
174+
</th>
175+
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider whitespace-nowrap">
176+
Type
177+
</th>
178+
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider min-w-[300px]">
179+
Path
180+
</th>
181+
<th
182+
className="px-4 py-2 text-right text-xs font-medium text-muted-foreground uppercase tracking-wider w-32 cursor-pointer hover:text-foreground transition-colors"
183+
onClick={toggleSort}
184+
>
185+
<div className="flex items-center justify-end gap-1">
186+
<span>Size</span>
187+
{getSortIcon()}
188+
</div>
189+
</th>
190+
</tr>
191+
</thead>
192+
<tbody className="divide-y divide-border/40">
193+
{sortedItems.map((item) => (
194+
<Row
195+
key={item.path}
196+
item={item}
197+
isSelected={selectedPaths.includes(item.path)}
198+
onToggleSelection={onToggleSelection}
199+
/>
200+
))}
201+
</tbody>
202+
</table>
119203
</div>
120204
);
121205
}

frontend/src/components/settings-dialog.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { useTheme } from './theme-provider'
2323
import { GetSettings, UpdateSettings } from '../../wailsjs/go/main/App'
2424
import { services } from '../../wailsjs/go/models'
2525
import { useToast } from '@/components/ui/use-toast'
26+
import { CheckForUpdatesButton } from './update-notification'
2627

2728
interface SettingsDialogProps {
2829
open: boolean
@@ -158,7 +159,7 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
158159
</div>
159160

160161
{/* Confirm Delete */}
161-
<div className="flex items-center justify-between">
162+
<div className="flex items-center justify-between mb-4">
162163
<div className="space-y-0.5">
163164
<Label htmlFor="confirmDelete">Confirm before deleting</Label>
164165
<p className="text-xs text-muted-foreground">
@@ -171,6 +172,21 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
171172
onCheckedChange={(checked) => updateSetting('confirmDelete', checked)}
172173
/>
173174
</div>
175+
176+
{/* Check Auto Update */}
177+
<div className="flex items-center justify-between">
178+
<div className="space-y-0.5">
179+
<Label htmlFor="checkAutoUpdate">Check for updates on startup</Label>
180+
<p className="text-xs text-muted-foreground">
181+
Automatically check for new versions
182+
</p>
183+
</div>
184+
<Switch
185+
id="checkAutoUpdate"
186+
checked={settings.checkAutoUpdate}
187+
onCheckedChange={(checked) => updateSetting('checkAutoUpdate', checked)}
188+
/>
189+
</div>
174190
</div>
175191

176192
{/* Scan Settings */}
@@ -193,6 +209,12 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
193209
</p>
194210
</div>
195211
</div>
212+
213+
{/* Updates */}
214+
<div className="border-t pt-4">
215+
<h4 className="text-sm font-medium mb-4">Updates</h4>
216+
<CheckForUpdatesButton />
217+
</div>
196218
</div>
197219
) : (
198220
<div className="py-8 text-center text-muted-foreground">

0 commit comments

Comments
 (0)