[READ-ONLY] Mirror of https://github.com/probablykasper/time-machine-inspector. Time Machine backup size inspector app
backup macos tauri time-machine
0

Configure Feed

Select the types of activity you want to include in your feed.

Make loading indicator per-page

Kasper (Jan 17, 2022, 4:06 AM +0100) c5dc5d1f a737f92f

+173 -71
+67 -20
src-tauri/src/cmd.rs
··· 1 1 use crate::dir_map::DirMap; 2 2 use crate::{compare, listbackups, throw}; 3 + use serde::Serialize; 3 4 use std::collections::HashMap; 4 5 use std::fs::File; 5 6 use std::process::ExitStatus; ··· 68 69 } 69 70 70 71 #[command] 71 - pub async fn load_backups( 72 + pub async fn load_backup_list( 72 73 refresh: bool, 73 74 w: Window, 74 75 state: State<'_, BackupList>, ··· 88 89 89 90 Ok(dir_map) 90 91 } 92 + 93 + #[derive(Serialize, Clone)] 94 + pub struct LoadedBackup { 95 + pub old: String, 96 + pub new: String, 97 + pub map: DirMap<u64>, 98 + pub loading: bool, 99 + } 100 + pub type LoadedBackupsMap = HashMap<(String, String), LoadedBackup>; 91 101 92 102 #[derive(Default)] 93 - pub struct BackupDirMaps(pub Mutex<HashMap<(String, String), DirMap<u64>>>); 103 + pub struct LoadedBackups(pub Mutex<LoadedBackupsMap>); 94 104 95 - impl BackupDirMaps { 96 - pub fn lock(&self) -> Result<MutexGuard<HashMap<(String, String), DirMap<u64>>>, String> { 105 + impl LoadedBackups { 106 + pub fn lock(&self) -> Result<MutexGuard<LoadedBackupsMap>, String> { 97 107 match self.0.lock() { 98 108 Ok(mutex) => Ok(mutex), 99 109 Err(e) => throw!("Unable to lock backup list: {}", e), ··· 101 111 } 102 112 } 103 113 114 + #[derive(Serialize, Clone)] 115 + pub struct BackupInfo { 116 + pub old: String, 117 + pub new: String, 118 + pub loading: bool, 119 + } 120 + 104 121 #[command] 105 - pub async fn compare_backups<'a>( 122 + pub async fn backups_info(state: State<'_, LoadedBackups>) -> Result<Vec<BackupInfo>, String> { 123 + let map = state.lock()?; 124 + let info = map.values().map(|b| BackupInfo { 125 + old: b.old.clone(), 126 + new: b.new.clone(), 127 + loading: b.loading, 128 + }); 129 + Ok(info.collect()) 130 + } 131 + 132 + #[command] 133 + pub async fn get_backup<'a>( 106 134 old: String, 107 135 new: String, 108 136 refresh: bool, 109 137 w: Window, 110 - state: State<'_, BackupDirMaps>, 138 + state: State<'_, LoadedBackups>, 111 139 ) -> Result<DirMap<u64>, String> { 112 140 let old_new = (old.clone(), new.clone()); 113 141 114 142 // get cached dir_map 115 143 if !refresh { 116 - let dir_maps = state.lock()?; 117 - match dir_maps.get(&old_new) { 118 - Some(dir_map) => { 119 - return Ok(dir_map.clone()); 144 + let loaded_backups = state.lock()?; 145 + match loaded_backups.get(&old_new) { 146 + Some(loaded_backup) => { 147 + return Ok(loaded_backup.map.clone()); 120 148 } 121 149 None => {} 122 150 } 123 151 } 124 152 153 + { 154 + let mut loaded_backups = state.lock()?; 155 + match loaded_backups.get_mut(&old_new) { 156 + Some(loaded_backup) => { 157 + if (loaded_backup).loading { 158 + throw!("Already loading backup"); 159 + } 160 + } 161 + None => { 162 + let backup = LoadedBackup { 163 + old: old.clone(), 164 + new: new.clone(), 165 + map: DirMap::new(), 166 + loading: true, 167 + }; 168 + loaded_backups.insert(old_new.clone(), backup); 169 + } 170 + } 171 + } 172 + 125 173 full_disk_access(w).await?; 126 174 let dir_map = compare::compare(&old, &new)?; 127 - state.lock()?.insert(old_new, dir_map.clone()); 128 175 129 - Ok(dir_map.clone()) 130 - } 176 + let mut loaded_backups = state.lock()?; 177 + let backup = LoadedBackup { 178 + old, 179 + new, 180 + map: dir_map.clone(), 181 + loading: false, 182 + }; 183 + loaded_backups.insert(old_new, backup); 131 184 132 - #[command] 133 - pub async fn cached_backups( 134 - state: State<'_, BackupDirMaps>, 135 - ) -> Result<Vec<(String, String)>, String> { 136 - let dir_maps = state.lock()?; 137 - let cached_paths = dir_maps.keys().map(|t| t.clone()).collect(); 138 - Ok(cached_paths) 185 + Ok(dir_map) 139 186 }
+4 -4
src-tauri/src/main.rs
··· 42 42 43 43 tauri::Builder::default() 44 44 .manage(cmd::BackupList(Default::default())) 45 - .manage(cmd::BackupDirMaps(Default::default())) 45 + .manage(cmd::LoadedBackups(Default::default())) 46 46 .invoke_handler(tauri::generate_handler![ 47 47 error_popup, 48 - cmd::load_backups, 49 - cmd::compare_backups, 50 - cmd::cached_backups, 48 + cmd::load_backup_list, 49 + cmd::get_backup, 50 + cmd::backups_info, 51 51 ]) 52 52 .create_window("main", WindowUrl::default(), |win, webview| { 53 53 let win = win
-2
src/App.svelte
··· 17 17 closePage() 18 18 await loadBackups(refresh) 19 19 let timeRemaining = Math.max(minEndTime - Date.now()) 20 - console.log(timeRemaining) 21 - 22 20 await new Promise((resolve) => { 23 21 setTimeout(resolve, timeRemaining) 24 22 })
+42 -6
src/Item.svelte
··· 3 3 name: string 4 4 dir: string 5 5 fullPath: string 6 + prevPath: string | null 6 7 isFolder: boolean 7 8 toggleChildren: () => void 8 9 } ··· 11 12 12 13 <script lang="ts"> 13 14 import { createEventDispatcher } from 'svelte' 14 - import { cachedBackups } from './page' 15 - import type { DirMap } from './page' 15 + import { backupInfos } from './page' 16 + import type { DirMap, BackupInfo } from './page' 16 17 17 18 export let map: DirMap 18 19 19 20 export let name: string 20 21 export let dir: string 21 22 export let fullPath: string 23 + export let prevPath: string | null 22 24 $: isFolder = map[fullPath] !== undefined 23 - $: isCached = $cachedBackups.includes(fullPath) 25 + 26 + let loadState = 0 27 + function updateLoadState(backupInfos: BackupInfo[]) { 28 + for (const info of backupInfos) { 29 + if (info.old === prevPath && info.new === fullPath) { 30 + if (info.loading) { 31 + loadState = 1 32 + } else { 33 + loadState = 2 34 + } 35 + } 36 + } 37 + } 38 + $: updateLoadState($backupInfos) 39 + 40 + let children: string[] = [] 41 + $: if (isFolder) { 42 + children = Object.keys(map[fullPath]).sort() 43 + } else { 44 + children = [] 45 + } 24 46 25 47 export let selectedPath: string 26 48 export let open = true ··· 33 55 dispatch('click', { 34 56 name, 35 57 fullPath, 58 + prevPath, 36 59 dir, 37 60 isFolder, 38 61 toggleChildren: () => { ··· 45 68 <div 46 69 class="item" 47 70 class:open 48 - class:is-cached={isCached} 71 + class:loading={loadState === 1} 72 + class:loaded={loadState === 2} 49 73 on:click={itemClick} 50 74 style={`padding-left: ${14 * indentLevel + 10}px`} 51 75 class:selected={selectedPath === fullPath + '/' + name}> ··· 57 81 </div> 58 82 <div class="children"> 59 83 {#if open && isFolder} 60 - {#each Object.keys(map[fullPath]).sort() as child} 84 + {#each children as child, i} 61 85 <svelte:self 62 86 {map} 63 87 name={child} 64 88 dir={fullPath} 65 89 fullPath={fullPath + '/' + child} 90 + prevPath={i === 0 ? null : fullPath + '/' + children[i - 1]} 66 91 {selectedPath} 67 92 on:click 68 93 indentLevel={indentLevel + 1} /> ··· 71 96 </div> 72 97 73 98 <style lang="sass"> 99 + $ease-md: cubic-bezier(0.4, 0.0, 0.2, 1) 74 100 .item 75 101 font-size: 14px 76 102 color: hsla(216, 50%, 70%, 0.75) ··· 79 105 padding: 4px 0px 80 106 box-sizing: border-box 81 107 width: 100% 108 + 109 + .loading:not(.selected) 110 + animation: flash 1s $ease-md infinite alternate 111 + 112 + @keyframes flash 113 + 0% 114 + color: hsla(216, 50%, 70%, 0.5) 115 + 100% 116 + color: hsla(216, 50%, 70%, 0.9) 117 + 82 118 .selected 83 119 background-color: hsla(216, 70%, 70%, 0.2) 84 120 .open svg 85 121 transform: rotate(90deg) 86 - .is-cached 122 + .loaded 87 123 font-weight: 600 88 124 color: hsla(216, 50%, 80%, 0.9) 89 125 svg
+14 -18
src/Page.svelte
··· 1 1 <script lang="ts"> 2 2 import { runCmd } from './general' 3 3 import PageItem, { ItemClickEvent } from './PageItem.svelte' 4 - import { backups, page, loadCachedBackups, cachedBackups } from './page' 4 + import { backups, page, backupInfos } from './page' 5 5 import Button from './lib/Button.svelte' 6 6 import ProgressBar from './lib/ProgressBar.svelte' 7 - import { fade } from 'svelte/transition' 8 - import { cubicInOut } from 'svelte/easing' 9 7 10 8 let selectedPath = '' 11 9 ··· 17 15 } 18 16 let dirMap: DirMap | null = null 19 17 20 - let loading = false 21 18 async function compare(autoLoad = false) { 22 - if (loading || $backups === null) { 19 + if ($page.loading || $backups === null) { 23 20 return 24 21 } 25 22 const fullPathParent = $page.fullPath.substring(0, $page.fullPath.lastIndexOf('/')) 26 23 const dir = $backups.dirs[fullPathParent][$page.name] 27 24 if (dir !== undefined) { 28 25 if (!autoLoad) { 29 - loading = true 26 + $page.loading = true 30 27 } 31 - console.log($page) 32 - 33 - const result = (await runCmd('compare_backups', { 28 + const result = (await runCmd('get_backup', { 34 29 old: $page.prevPath, 35 30 new: $page.fullPath, 36 31 refresh: false, 37 32 })) as { map: DirMap; cached_paths: [string, string][] } 38 33 dirMap = result.map 39 - loadCachedBackups() 34 + backupInfos.load() 40 35 console.log(result) 41 36 } 42 - loading = false 43 37 } 44 38 45 - $: autoLoad($page.fullPath) 46 - function autoLoad(path: string) { 47 - if ($cachedBackups.includes(path)) { 48 - compare(true) 39 + $: autoLoad($page.prevPath, $page.fullPath) 40 + function autoLoad(oldPath: string, newPath: string) { 41 + for (const info of $backupInfos) { 42 + if (info.old === oldPath && info.new === newPath) { 43 + compare(true) 44 + } 49 45 } 50 46 } 51 47 ··· 67 63 <main> 68 64 <div class="bar">{$page.fullPath}</div> 69 65 <div class="content"> 70 - {#if loading} 71 - <div class="absolute center-align" transition:fade={{ duration: 500, easing: cubicInOut }}> 66 + {#if $page.loading} 67 + <div class="absolute center-align"> 72 68 <ProgressBar /> 73 69 </div> 74 70 {:else if dirMap === null || dirMap[rootPath] === undefined} 75 71 <div class="absolute center-align"> 76 - <Button disabled={loading} on:click={() => compare()}>Load</Button> 72 + <Button on:click={() => compare()}>Load</Button> 77 73 </div> 78 74 {:else} 79 75 {#each Object.entries(dirMap[rootPath]) as [childName, size]}
+11 -12
src/Sidebar.svelte
··· 3 3 import { page, Backups } from './page' 4 4 5 5 export let backups: Backups 6 + $: dir = Object.keys(backups.dirs[backups.rootPath]).sort() 6 7 7 8 function sidebarClick(e: ItemClickEvent) { 8 9 if (e.detail.isFolder) { 9 10 e.detail.toggleChildren() 10 - } else { 11 - const paths = Object.keys(backups.dirs[e.detail.dir]).sort() 12 - const backupIndex = paths.indexOf(e.detail.name) 13 - if (backupIndex >= 1) { 14 - let prevPathSeparator = e.detail.dir === '/' ? '' : '/' 15 - $page = { 16 - fullPath: e.detail.fullPath, 17 - name: e.detail.name, 18 - prevPath: e.detail.dir + prevPathSeparator + paths[backupIndex - 1], 19 - } 11 + } else if (e.detail.prevPath) { 12 + $page = { 13 + fullPath: e.detail.fullPath, 14 + name: e.detail.name, 15 + prevPath: e.detail.prevPath, 16 + loading: false, 20 17 } 21 18 } 22 19 } 23 20 </script> 24 21 25 22 <div class="content"> 26 - {#each Object.keys(backups.dirs[backups.rootPath]).sort() as child} 23 + {#each dir as child, i} 27 24 {#if backups.rootPath === '/'} 28 25 <Item 29 26 map={backups.dirs} 30 - selectedPath={$page.fullPath + '/' + $page.name} 31 27 name={child} 32 28 dir="/" 33 29 fullPath={'/' + child} 30 + prevPath={i === 0 ? null : '/' + dir[i - 1]} 31 + selectedPath={$page.fullPath + '/' + $page.name} 34 32 on:click={sidebarClick} /> 35 33 {:else} 36 34 <Item ··· 38 36 name={child} 39 37 dir={backups.rootPath} 40 38 fullPath={backups.rootPath + '/' + child} 39 + prevPath={i === 0 ? null : backups.rootPath + '/' + dir[i - 1]} 41 40 selectedPath={$page.fullPath + '/' + $page.name} 42 41 on:click={sidebarClick} /> 43 42 {/if}
+35 -9
src/page.ts
··· 1 - import { writable } from 'svelte/store' 1 + import { get, writable } from 'svelte/store' 2 2 import { runCmd } from './general' 3 3 4 4 export type Backups = { ··· 19 19 subscribe: store.subscribe, 20 20 set: (value: Backups | null) => { 21 21 store.set(value) 22 - loadCachedBackups() 22 + backupInfos.load() 23 23 }, 24 24 } 25 25 })() 26 26 27 - export const cachedBackups = writable([] as string[]) 28 - export async function loadCachedBackups() { 29 - const result = (await runCmd('cached_backups')) as [string, string][] 30 - const newOnly = result.map((b) => b[1]) 31 - cachedBackups.set(newOnly) 27 + export type BackupInfo = { 28 + old: string 29 + new: string 30 + loading: boolean 32 31 } 32 + export const backupInfos = (() => { 33 + const store = writable([] as BackupInfo[]) 34 + return { 35 + subscribe: store.subscribe, 36 + load: async () => { 37 + const result = (await runCmd('backups_info')) as BackupInfo[] 38 + const $page = get(page) 39 + result.find((info) => { 40 + if (info.old === $page.prevPath && info.new === $page.fullPath) { 41 + if ($page.loading !== info.loading) { 42 + page.set_loading(info.loading) 43 + } 44 + } 45 + }) 46 + store.set(result) 47 + }, 48 + } 49 + })() 33 50 34 51 type Page = { 35 52 fullPath: string 36 53 name: string 37 54 prevPath: string 55 + loading: boolean 38 56 } 39 57 export const page = (() => { 40 58 const store = writable<Page>({ 41 59 fullPath: '', 42 60 name: '', 43 61 prevPath: '', 62 + loading: false, 44 63 }) 45 64 return { 46 65 subscribe: store.subscribe, 47 66 set: (value: Page) => { 48 67 store.set(value) 49 - loadCachedBackups() 68 + backupInfos.load() 69 + }, 70 + set_loading: (value: boolean) => { 71 + store.update(($page) => { 72 + $page.loading = value 73 + return $page 74 + }) 50 75 }, 51 76 } 52 77 })() ··· 56 81 fullPath: '', 57 82 name: '', 58 83 prevPath: '', 84 + loading: false, 59 85 }) 60 86 backups.set(null) 61 87 } 62 88 63 89 export async function loadBackups(refresh = false) { 64 - const result = (await runCmd('load_backups', { refresh })) as { map: DirMap } 90 + const result = (await runCmd('load_backup_list', { refresh })) as { map: DirMap } 65 91 console.log(result) 66 92 const map = result.map 67 93