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
79 changes: 78 additions & 1 deletion app.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"aether/internal/blueprint"
"aether/internal/color"
"aether/internal/extraction"
"aether/internal/favexport"
"aether/internal/favorites"
"aether/internal/omarchy"
"aether/internal/platform"
Expand All @@ -37,6 +38,7 @@ type App struct {
writer *theme.Writer
blueprints *blueprint.Service
favorites *favorites.Service
favExport *favexport.Exporter
wallhaven *wallhaven.Client
batch *batch.Processor
themeWatcher *theme.ThemeWatcher
Expand Down Expand Up @@ -88,13 +90,15 @@ func (a *App) StartUpgrade() error {

// NewApp creates a new App instance.
func NewApp() *App {
wh := wallhaven.NewClient()
return &App{
state: newSeededState(),
history: theme.NewHistoryManager(),
writer: theme.NewWriter(EmbeddedTemplates, "templates"),
blueprints: blueprint.NewService(),
favorites: favorites.NewService(),
wallhaven: wallhaven.NewClient(),
favExport: favexport.New(wh),
wallhaven: wh,
batch: batch.NewProcessor(),
themeWatcher: theme.NewThemeWatcher(),
}
Expand Down Expand Up @@ -644,6 +648,79 @@ func (a *App) IsFavorite(path string) bool {
return a.favorites.IsFavorite(path)
}

// ExportFavoritesRequest is the payload from the frontend for zipping favorites.
type ExportFavoritesRequest struct {
Paths []string `json:"paths"` // favorite paths, in display order
}

// ExportFavorites archives the given favorites into a .zip in a user-chosen
// directory. Wallhaven favorites are remote URLs, so anything not already
// downloaded is fetched first — which makes this slow enough that the work runs
// in the background and reports through favorites-export-* events. Returns the
// path the archive is being written to.
func (a *App) ExportFavorites(req ExportFavoritesRequest) (string, error) {
items := a.favoriteItems(req.Paths)
if len(items) == 0 {
return "", fmt.Errorf("no favorites to export")
}

dir, err := wailsrt.OpenDirectoryDialog(a.ctx, wailsrt.OpenDialogOptions{
Title: "Choose Export Directory",
CanCreateDirectories: true,
})
if err != nil || dir == "" {
return "", fmt.Errorf("export cancelled")
}

return a.favExport.Start(a.ctx, items, dir)
}

// CancelFavoritesExport stops a running favorites export.
func (a *App) CancelFavoritesExport() { a.favExport.Cancel() }

// IsFavoritesExportRunning reports whether an export is in flight. The frontend
// uses this to recover its progress state after a reload.
func (a *App) IsFavoritesExportRunning() bool { return a.favExport.IsRunning() }

// favoriteItems resolves frontend-supplied paths against the favorites store.
// Only the path crosses the boundary — names and metadata are read back from
// the service so the archive cannot be steered by the caller.
func (a *App) favoriteItems(paths []string) []favexport.Item {
known := make(map[string]favorites.Favorite)
for _, fav := range a.favorites.GetAll() {
known[fav.Path] = fav
}

items := make([]favexport.Item, 0, len(paths))
seen := make(map[string]bool, len(paths))
for _, path := range paths {
fav, ok := known[path]
if !ok || seen[path] {
continue
}
seen[path] = true

item := favexport.Item{Path: fav.Path, Meta: map[string]interface{}{}}
if fav.Type != "" {
item.Meta["type"] = fav.Type
}
for k, v := range fav.Data {
if v == nil {
continue
}
item.Meta[k] = v
}
// The tile label is the local name, falling back to the wallhaven id.
if name, ok := fav.Data["name"].(string); ok {
item.Name = name
} else if id, ok := fav.Data["id"].(string); ok {
item.Name = id
}
items = append(items, item)
}
return items
}

// ---------------------------------------------------------------------------
// App Settings (template toggles, neovim config)
// ---------------------------------------------------------------------------
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
import OmarchyThemes from '$lib/components/blueprints/OmarchyThemes.svelte';
import SettingsView from '$lib/components/settings/SettingsView.svelte';
import AboutView from '$lib/components/layout/AboutView.svelte';
import ExportProgress from '$lib/components/favorites/ExportProgress.svelte';
import {initExportEvents} from '$lib/stores/favoritesExport.svelte';
import {
getActiveTab,
setActiveTab,
Expand Down Expand Up @@ -279,6 +281,10 @@
else if (getKeymapOpen()) setKeymapOpen(false);
});

// Favorites export progress. Wired here rather than in FavoritesView
// so an export keeps reporting after the user switches tabs.
initExportEvents();

// Listen for events from Go
(async () => {
try {
Expand Down Expand Up @@ -477,6 +483,7 @@
<TargetAppsStrip />
{/if}
<ActionBar />
<ExportProgress />
<Toast />
<KeymapDialog open={getKeymapOpen()} onclose={() => setKeymapOpen(false)} />
<CommandPalette
Expand Down
57 changes: 57 additions & 0 deletions frontend/src/lib/components/favorites/ExportProgress.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<script lang="ts">
import {
getExportState,
cancelExport,
} from '$lib/stores/favoritesExport.svelte';

let state = $derived(getExportState());
let percent = $derived(
state.total > 0
? Math.min(100, Math.round((state.index / state.total) * 100))
: 0
);
let label = $derived(
state.phase === 'archive' ? 'Archiving' : 'Downloading'
);
</script>

{#if state.active}
<!--
Sits directly above the ActionBar footer (h-10). App chrome, not an image
overlay, so it uses theme tokens and stays legible in light mode.
-->
<div
class="bg-bg-secondary border-border fixed bottom-10 left-0 right-0 z-[90] border-t"
>
<div class="flex items-center gap-3 px-3 py-1.5">
<span class="text-fg-secondary shrink-0 text-[11px]">
{label}
{#if state.total > 0}{state.index}/{state.total}{/if}
</span>
{#if state.name}
<span class="text-fg-dimmed min-w-0 flex-1 truncate text-[11px]"
>{state.name}</span
>
{:else}
<span class="min-w-0 flex-1"></span>
{/if}
<button
class="text-destructive/60 hover:text-destructive hover:bg-bg-hover shrink-0 px-2 py-1 text-[11px] transition-colors duration-100"
onclick={cancelExport}>Cancel</button
>
</div>
<div
class="bg-bg-surface h-1 w-full"
role="progressbar"
aria-label="Favorites export progress"
aria-valuenow={percent}
aria-valuemin={0}
aria-valuemax={100}
>
<div
class="bg-accent h-full transition-[width] duration-150"
style:width="{percent}%"
></div>
</div>
</div>
{/if}
14 changes: 13 additions & 1 deletion frontend/src/lib/components/favorites/FavoritesView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
getCachedFullImage,
} from '$lib/stores/imagecache.svelte';
import {getLabels, getAssignments} from '$lib/stores/tags.svelte';
import {
getExportState,
startExport,
} from '$lib/stores/favoritesExport.svelte';
import WallpaperTile from '$lib/components/shared/WallpaperTile.svelte';
import ImagePreview from '$lib/components/shared/ImagePreview.svelte';
import EmptyState from '$lib/components/shared/EmptyState.svelte';
Expand Down Expand Up @@ -193,7 +197,15 @@
{/each}
{/if}

<span class="text-fg-dimmed ml-auto text-[10px]"
<button
class="bg-accent text-accent-fg hover:bg-accent-hover ml-auto px-2 py-0.5 text-[10px] font-medium transition-colors duration-100 disabled:opacity-50"
disabled={filtered.length === 0 || getExportState().active}
onclick={() => startExport(filtered.map(f => f.path))}
title="Export the listed favorites as a .zip archive"
>Export .zip ({filtered.length})</button
>

<span class="text-fg-dimmed text-[10px]"
>{filtered.length}{filterTag ? `/${favorites.length}` : ''}</span
>
</ViewHeader>
Expand Down
145 changes: 145 additions & 0 deletions frontend/src/lib/stores/favoritesExport.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import {showToast} from '$lib/stores/ui.svelte';
import type {main} from '../../../wailsjs/go/models';

export type ExportPhase = 'download' | 'archive';

export type ExportState = {
active: boolean;
phase: ExportPhase;
index: number; // 1-based, counts items handled in the current phase
total: number;
name: string; // file currently being handled
zipPath: string;
};

type Skip = {path: string; reason: string};

type ExportResult = {
zipPath: string;
total: number;
exported: number;
skipped: Skip[] | null;
};

const IDLE: ExportState = {
active: false,
phase: 'download',
index: 0,
total: 0,
name: '',
zipPath: '',
};

let state = $state<ExportState>({...IDLE});
let eventsReady = false;
// Bumped by every terminal event. The backend starts working the moment the
// directory picker closes, which can be before ExportFavorites' promise
// settles, so startExport uses this to tell "my run is still going" from
// "my run already finished".
let runSeq = 0;

export function getExportState(): ExportState {
return state;
}

/**
* Starts an export of the given favorite paths. The backend opens the
* directory picker itself, so a dismissed dialog surfaces as a "cancelled"
* error we swallow — same convention as the theme import flow in ActionBar.
*/
export async function startExport(paths: string[]): Promise<void> {
if (state.active || paths.length === 0) return;

const seq = runSeq;
try {
const {ExportFavorites} = await import('../../../wailsjs/go/main/App');
const zipPath = await ExportFavorites({
paths,
} as unknown as main.ExportFavoritesRequest);

if (runSeq !== seq) return; // already finished while we were awaiting
state = state.active
? {...state, zipPath} // progress is already flowing; don't rewind it
: // Seed the bar so it shows up the moment the picker closes
// rather than only after the first download lands.
{...IDLE, active: true, total: paths.length, zipPath};
} catch (e: any) {
const message = e?.message ?? String(e);
if (message.includes('cancelled')) return;
showToast(message || 'Export failed');
}
}

export async function cancelExport(): Promise<void> {
if (!state.active) return;
try {
const {CancelFavoritesExport} = await import(
'../../../wailsjs/go/main/App'
);
await CancelFavoritesExport();
} catch {
// The backend either finished or was never running; the terminal event
// still resets the state.
}
}

/**
* Subscribes to the backend's export events. Called once from App.svelte so
* progress survives switching tabs away from Favorites.
*/
export async function initExportEvents(): Promise<void> {
if (eventsReady) return;
eventsReady = true;

const {EventsOn, BrowserOpenURL} = await import(
'../../../wailsjs/runtime/runtime'
);

EventsOn(
'favorites-export-progress',
(p: {phase: ExportPhase; index: number; total: number; name: string}) =>
(state = {...state, active: true, ...p})
);

EventsOn('favorites-export-completed', (result: ExportResult) => {
runSeq++;
state = {...IDLE};
const skipped = result.skipped?.length ?? 0;
const summary = skipped
? `Exported ${result.exported} of ${result.total} favorites`
: `Exported ${result.exported} favorite${result.exported === 1 ? '' : 's'}`;
const dir = result.zipPath.slice(0, result.zipPath.lastIndexOf('/'));
showToast(`${summary} to ${result.zipPath}`, {
duration: 8000,
action: {
label: 'Open folder',
run: () => BrowserOpenURL('file://' + dir),
},
});
});

EventsOn('favorites-export-failed', (p: {error: string}) => {
runSeq++;
state = {...IDLE};
showToast(p?.error || 'Export failed');
});

EventsOn('favorites-export-cancelled', () => {
runSeq++;
state = {...IDLE};
showToast('Export cancelled');
});

// The backend can already be exporting if the frontend reloaded mid-run
// (dev hot reload); progress events refill the details.
try {
const {IsFavoritesExportRunning} = await import(
'../../../wailsjs/go/main/App'
);
if (await IsFavoritesExportRunning()) {
state = {...state, active: true};
}
} catch {
// No backend (browser-only dev) — nothing to recover.
}
}
Loading